Global Illumination - Sixth 3D
Table of Contents
1. What global illumination adds
Plain per-polygon shading lights every polygon with one flat color: no shadows, and surfaces that receive no direct light stay uniformly dark. A room corner reads as a flat silhouette instead of a corner.
Sixth 3D can optionally compute global illumination progressively on background CPU threads: shadows appear, light pools under lamps with smooth falloff, and colored light bleeds — a red sofa tints the floor next to it red. All of it converges gradually over the first seconds of a scene, then idles.
Same camera, same house: flat shading (top) versus converged GI (below). Note the soft shadow of the partition wall, the lamp glow on the ceiling, and the subtle color variation across the floor.
2. The big idea: GI off the render path
Ray tracing is far too slow to run per frame in a software renderer, so it doesn't: the render loop never traces a single ray. Painting a lightmapped triangle is an ordinary texture lookup, exactly as fast as any textured polygon.
All the expensive work happens on dedicated low-priority worker threads that continuously refine per-surface lighting values. Whenever the values have improved enough, the workers regenerate each triangle's composite texture (baseColor x total lighting) into a back buffer and swap it in atomically — painters never see a half-updated texture.
Because painters only read finished textures, frame rate is completely decoupled from GI quality: you can crank lightmap resolution up and the only cost is CPU time on the worker threads, not frame time.
3. Lightmaps: a texture per triangle
Flat shading can only color a polygon uniformly — shadows and gradients need resolution inside the polygon. Every lightmapped triangle therefore owns a small generated texture, its lightmap, whose texels map onto the triangle surface by an affine rule:
The triangle's UVs are pinned to (0,0), (1,0), (0,1), so the valid texel region is the half where u+v <= 1; the other half of the square texture is flood-filled from valid neighbors so that nearest sampling near the hypotenuse never picks up garbage.
Each texel stores two things, both written only by GI threads:
- Indirect irradiance (RGB floats) — the accumulated bounced light.
- Per-light visibility bits — whether the last shadow ray from this texel reached each lamp (cached so later queries cost nothing).
Resolution is set in world units per texel
(BspCompositeShape.setLightmapUnitsPerTexel(), default 12): a 100-unit
wall cell gets an 8x8 lightmap. Halving the units quadruples the
tracing work.
What you trace is what you see: the composite texture the painter samples is the lightmap itself, at native texel resolution — there is no upscaling step. Shadow-edge smoothness comes from tracing at finer resolution, never from interpolation. (An earlier bilinear-upscaling pass produced visibly artificial results and was removed; finer texels cost more CPU on the GI threads but look right.)
4. One sample: a shadow ray and a bounce ray
The work list is flat: one item per lightmap texel (and one per plain polygon, see below). Worker threads walk it round-robin, and every visit to a texel casts exactly two rays from the texel's world position, nudged slightly off the surface along the normal:
- Shadow ray toward a lamp. Answers visible/occluded, cached in the texel's visibility bits. On a texel's first visit all lamps are tested at once, so direct light and hard shadows appear after a single sweep instead of trickling in lamp by lamp; later visits re-test one lamp at a time, round-robin.
- Bounce ray in a random cosine-weighted direction around the normal. Wherever it lands (point Q), the sample reads Q's direct lighting — using Q's cached shadow bits, no new shadow rays — plus Q's current indirect estimate, and blends the sum into the texel's own indirect value.
Reading Q's current indirect estimate instead of recursing is what
makes bounce light propagate: sweep 1 learns "Q is directly lit",
sweep 2 learns "P sees a lit Q", sweep 3 learns "R sees a lit P"…
Light ripples one surface deeper with every sweep, with no recursion
limit and no exponential ray explosion. The 1/pi diffuse gain keeps
the feedback loop from diverging: without it the indirect term
amplifies itself and the scene saturates to white.
5. Progressive convergence
Monte Carlo samples are noisy, so blending happens at two nested levels, both exponential moving averages:
- Inner, per sample: each texel blends every new bounce-ray result
into its indirect estimate with a constant weight (
alpha = 0.15, modefixed). Every ray hit stays equally intensive forever — an unlit area fades to darkness at the same rate a lit area brightens. (The oldadaptivemode, which decays alpha with sample count, is still available; see the knobs below.) - Outer, per composite update: the value that reaches the screen is
a second EMA over the COMPLETE sum
ambient + direct + indirect. The texture can only movee3d.gi.compositeAlpha(default 0.2) of the remaining distance per 500 ms update — so direct light, shadows and bounce light all glide in together over a few seconds, and no single-frame jump is possible by construction.
The world starts at a uniform medium irradiance
(e3d.gi.initialIrradiance, default 128): the scene is visible from
the very first frame, then lit areas brighten and unlit areas sink to
darkness as the workers sweep — lights and shadows gradually become
distinguished instead of the old pitch-black start with a sudden flash
once the first sweep landed:
Consequences of the design:
- Hysteresis is free: when a lamp moves or geometry changes, old light fades out gradually instead of popping — the same pair of EMAs that accumulates light also drains it.
- Convergence detection: per-sample deltas are pure noise (and with a
constant alpha they never settle), so the system watches the average
per-texel movement of the on-screen estimate; after five composite
updates below
e3d.gi.calmThreshold(default 1.0 light unit) the workers drop to a low duty cycle (~50% of sweep time, capped) instead of burning CPU. Any scene or light change rebuilds the snapshot and restarts full-speed tracing. - Despeckle: at composite time the indirect channel is blended 50/50 with the mean of its valid 4-neighbors, killing single-texel Monte Carlo spikes without blurring real gradients.
- Composite cadence: textures regenerate at most every 500 ms — one atomic swap per triangle, invisible to painters.
6. Plain polygons get GI too
Surfaces that are not lightmapped (ordinary SolidPolygon with shading
enabled) still benefit, at per-polygon resolution, through the
GiLightProvider
interface that GlobalIllumination installs into the
LightingManager:
isLightVisible(polygon, light)answers from cached shadow bits — direct-light shadows fade in on flat-shaded geometry.addIndirectLight(polygon, baseColor, result)adds the polygon's bounced-light estimate into the flat-shaded color.
Both are called from parallel render-pool threads, so they only read volatile caches — never trace.
7. Enabling GI
// Per-texel lightmaps on BSP-compiled geometry (the House demo setup): BspCompositeShape house = new BspCompositeShape(); house.setLightmappingEnabled(true); house.setLightmapUnitsPerTexel(3.0); // fine texels: quality from traced rays // Start the workers (2 threads by default; more converge faster): viewPanel.enableGlobalIllumination(4);
Tuning knobs (system properties):
| Property | Default | Effect |
|---|---|---|
e3d.gi.alphaMode |
fixed | adaptive decays the inner EMA alpha with sample count |
e3d.gi.alphaFloor |
0.08 | adaptive-mode floor; higher adapts faster but noisier |
e3d.gi.compositeAlpha |
0.2 | outer EMA: fade speed of the on-screen estimate per 500 ms update |
e3d.gi.initialIrradiance |
128 | uniform medium start (0..255 light units) |
e3d.gi.calmThreshold |
1.0 | convergence: avg estimate movement (light units) |
e3d.gi.despeckle |
true | neighbor-smoothing of indirect at composite time |
e3d.gi.debug |
false | sweep statistics to stdout |
e3d.gi.dumpLightmaps |
(unset) | dump composite lightmaps as PNGs to the given dir |
8. Limitations
- Diffuse light only — no specular bounce, no caustics.
- Polygon vertices are traced in composite-local space; scenes that put non-identity transforms on composites are traced incorrectly.
- The bounce estimate is one ray deep per sample — correctness comes from sweep-over-sweep propagation, so deeply indirect corners take several sweeps to brighten.